[後編] AWS CDKで API Gateway + Lambda 構成のREST APIを構築して Auth0 + Lambda Authorizerの認可機能を導入してみた
CX事業本部Delivery部のアベシです。
こちらの記事では、API Gateway + Lambda のREST APIに Auth0 + Lambda Authorizerの認可を導入する方法について紹介します。 前回公開した前編の後編となります。 前編はこちら⇣
後編の内容
後編では以下の内容を紹介したいと思います。
構成と認可の流れ
前編のおさらいで認可の流れを記載した構成図を再度掲載します。
① クライアントがAuth0に認可をリクエストする。
② 認可されたらAuth0がアクセストークンを返す。
③ クライアントがAPIコールする。その際にアクセストークンをヘッダーとしてAPI Gatewayに渡す。
④ API GatewayがLambda Authorizerにアクセストークンを渡して関数を実行する。
⑤ Lambda Authorizerはアクセストークンを用いてAuth0へ公開鍵の取得をリクエストする。
⑥ Auth0が公開鍵をLambda Authorizerに返し、アクセストークンの RS256 署名の検証を実行する。
⑦ 検証が成功したらAPIを実行するために必要なポリシーを作成しAPI Gatewayに渡す。
⑧ API GatewayがLambda関数を実行する。
Auth0の設定
今回アクセストークンにはCLIを用いてcurlで入手しますが、このような認証にはM2M(MACHINE TO MACHINE認証)を利用できます。
まず、Auth0にM2M認証するためのアプリケーションを作成します。 APIを作成すると自動的にアプリケーションも作成されますのでその流れでやっていきます。
Auth0のご自分のアカウントにログインします。
左のペインのAPIs
をクリックします。
次にCREATE API
をクリックします。
するとモーダルウィンドウが立ち上がります。
Identifier
には前編で作成したAPI GatewayのURLを記入します。
Sining Algorithm
にはアクセストークンの署名方式を指定します。今回はデフォルトのRS256署名
を選択します。
左のペインのApplications
をクリックするとアプリケーションが作成されているのが確認できます。
curlでアクセストークンを取得する方法
Auth0に対して以下のcurlコマンドでリクエストしてアクセストークンを取得してみます。
- Domain : テナントのエンドポイント
- Client ID
- Client Secret
- 上記の3つはAuth0のアプリケーションの画面に記載されています。
- Identifier
- API作成時に指定したAPIGatewayのURL
- content-type
- x-www-form-urlencodedを指定
$ curl -X POST \ 'https://<Domain>/oauth/token' \ -H 'content-type: application/x-www-form-urlencoded' \ -d 'audience=<Identifier>&grant_type=client_credentials&client_id=<Client ID>&client_secret=<Client Secret>'
このリクエストに対してレスポンスでアクセストークンが返却されます。
{ "access_token": "******************************************", "expires_in":86400, "token_type": "Bearer" }
AWS側の設定
Lambda Authorizer用のLambda関数
コードは以下のページを参考にTypeScriptで書きました。
構築環境
以下の環境で構築と動作確認しています。
項目名 | バージョン |
---|---|
mac OS | Ventura 13.2 |
typeScript | 4.9.5 |
jwks-rsa | 3.0.1 |
jsonwebtoken | 9.0.0 |
import { APIGatewayAuthorizerEvent, APIGatewayAuthorizerResult, } from 'aws-lambda'; import * as jwt from 'jsonwebtoken'; import * as util from 'util'; import * as jwks from 'jwks-rsa'; import ms from 'ms'; // event内のアクセストークンを取得 const getToken = (event: APIGatewayAuthorizerEvent) => { // event内のアクセストークンを取得 if (event.type !== 'TOKEN') { throw new Error(`event.type parameter must have value TOKEN , but actual value is ${event.type}`); } const token = event.authorizationToken; if (!token) { throw new Error("event.authorizationToken parameter must be set, but got null"); } return token }; // アクセストークンの検証 const verifyToken = async(token: string): Promise<string | jwt.JwtPayload> => { // jwt形式のアクセストークンをデコード const decodedToken = jwt.decode(token, { complete: true }); if (!decodedToken || !decodedToken.header || !decodedToken.header.kid) { throw new jwt.JsonWebTokenError('invalid token'); } const client = new jwks.JwksClient({ cache: true, cacheMaxAge:ms('1h') , jwksUri: process.env.JWKS_URI as string, }); try { const getSigningKey = util.promisify(client.getSigningKey); const key = await getSigningKey(decodedToken.header.kid); const signingKey = (key as jwks.CertSigningKey).publicKey || (key as jwks.RsaSigningKey).rsaPublicKey; const tokenInfo = jwt.verify(token, signingKey, { audience: process.env.AUDIENCE, issuer: process.env.TOKEN_ISSUER, }); return tokenInfo; } catch (err) { if (err instanceof jwt.TokenExpiredError) { throw new Error('token expired'); } if (err instanceof jwt.JsonWebTokenError) { throw new Error('token is invalid'); } throw err; } } // 認可ポリシーの生成 const generatePolicy = ( principal: string, effect: 'Allow' | 'Deny', resource: string, ): APIGatewayAuthorizerResult =>{ return { principalId: principal, policyDocument: { Version: '2012-10-17', Statement: [ { Action: 'execute-api:Invoke', // API Gateway にデプロイした API を呼び出しするアクション Effect: effect, Resource: resource, }, ], }, }; } export const handler = async ( event: APIGatewayAuthorizerEvent, ): Promise<APIGatewayAuthorizerResult> => { console.log('event', JSON.stringify(event, undefined, 2)); try { const token = getToken(event); const res = await verifyToken(token); return generatePolicy(res.sub as string, 'Allow', event.methodArn); //methodARNにリクエストのメソッドとリソースパスが入っている } catch (error) { console.log(error); return generatePolicy(" ", 'Deny', event.methodArn); } };
コードの解説
eventの中身
API GatewayからLambdaに渡されるevent
は以下の要素を持ちます。
authorizationToken
がアクセストークンですね
{ "type": "TOKEN", "methodArn": "arn:aws:execute-api:ap-northeast-1:***********:**********/v1/GET/hello_world", "authorizationToken": "********************************************************" }
トークン取得
以下の構文でアクセストークンをevent要素の中から取得します。
const getToken = (event: APIGatewayAuthorizerEvent) => { // event内のアクセストークンを取得 if (event.type !== 'TOKEN') { throw new Error(`event.type parameter must have value TOKEN , but actual value is ${event.type}`); } const token = event.authorizationToken; if (!token) { throw new Error("event.authorizationToken parameter must be set, but got null"); } return token };
トークンの検証
JWTのトークンをデコードします
const decodedToken = jwt.decode(token, { complete: true }); if (!decodedToken || !decodedToken.header || !decodedToken.header.kid) { throw new jwt.JsonWebTokenError('invalid token'); }
base64でデコードしたトークンは以下のクレームを持っております。
{ iss: 'https://integrate-lambda-authorizer-test.jp.auth0.com/', sub: '**********************************@clients', aud: 'https://**********.execute-api.ap-northeast-1.amazonaws.com', iat: 1678755454, exp: 1678841854, azp: '*****************************', gty: 'client-credentials' }
- subクレーム
- ユーザーの一意識別子が入っており、後に認可ポリシーを作成する際にプリンシパルとして利用します。
- audクレーム
- Audienceの事でAPIの作成の際に
Identifier
に記載したAPI GatewayのURLが入ってます。
- Audienceの事でAPIの作成の際に
jwtトークンのクレームについては以下のページの説明が非常に参考になりました。
次にデコードしたアクセストークンを検証します。
Lambda関数の環境変数に登録したJWKS_URI
はAuth0
が公開しているJWK
取得先のエンドポイントになります。
こちらからJWK
取得し、その公開鍵
を使ってアクセストークンの署名を検証する流れとなります。
JWK
はJWT
の発行元から提供される公開鍵情報です。
こちらの公開鍵ですが、クライアントからのリクエストのたびにエンドポイントから取得すると、Lambdaの稼働時間が増えることによりコストの上昇やパフォーマンス低下が発生します。
その回避方法としてjwks-rsaモ
ジュールのJwksClient
クラスの公開鍵をキャッシュする機能が使えます。
リクエストの際に渡されたアクセストークンのkid
の値とキャッシュした鍵のそれが一致する場合、キャッシュから鍵が返されます。
kid (key ID) パラメータは特定の鍵を識別するために用いられます。
有効にするにはプロパティのcache
の値をtrue
とします。
chachMaxAge
プロパティを使用してキャッシュする時間の設定が可能です。
より詳しい使い方は以下のページを参照していただければと思います・
const client = new jwks.JwksClient({ cache: true, cacheMaxAge:ms('1h') , jwksUri: process.env.JWKS_URI as string, }); try { const getSigningKey = util.promisify(client.getSigningKey); const key = await getSigningKey(decodedToken.header.kid); const signingKey = (key as jwks.CertSigningKey).publicKey || (key as jwks.RsaSigningKey).rsaPublicKey;
jsonwebtoken
モジュールのverify
関数を使用してトークンを検証します。
その際にLambda関数の環境変数に登録したAUDIENCE
とTOKEN_ISSUER
を使用します。
AUDIENCE
にはAPI作成時に指定したIdentity
, TOKEN_ISSUER
にはAuth0のテナントのエンドポイントを指定します。
テナントのエンドポイントは先程のcurlコマンドで指定したDomain
の値です。
const tokenInfo = jwt.verify(token, signingKey, { audience: process.env.AUDIENCE, issuer: process.env.TOKEN_ISSUER, }); return tokenInfo;
認可トークンの生成部分
アクセストークンの検証が無事に完了すると、以下のコードに定義したポリシーを生成します。
ハンドラーはこのポリシーをAPI Gatewayにコールバックします。
各要素については以下のとなります。
principalId
- トークン内のsubクレームを渡します。
Action
にはAPI Gateway- デプロイした API を呼び出しするアクションの
execute-api:Invoke
を指定します。
- デプロイした API を呼び出しするアクションの
Effect
- 今回は許可する権限を生成したいので
Allow
を渡します。
- 今回は許可する権限を生成したいので
Resource
- event内の
methodArn
を指定します。methodArn
にはAPI Gateway にデプロイした APIのメソッドとリソースパスが含まれています。
- event内の
const generatePolicy = ( principal: string, effect: 'Allow' | 'Deny', resource: string, ): APIGatewayAuthorizerResult =>{ return { principalId: principal, policyDocument: { Version: '2012-10-17', Statement: [ { Action: 'execute-api:Invoke', Effect: effect, Resource: resource, }, ], }, }; }
Authorizer用のLambda関数の解説は以上です。 後は前編で作成したCDKをデプロイします。
動作確認
デプロイされたAPIのリソースの認可方法がlambdaAuthorizer
になっています。
Authorizersの画面を確認します。作成したLambda Authorizer用のLambda関数名が割当てられています。
それでは以下のcurlコマンドでAPIを叩いてみます。
ヘッダーに先程のcurlコマンドで取得したアクセストークンを指定します。
curl -X GET \ "https://**********.execute-api.ap-northeast-1.amazonaws.com/v1/hello_world" \ -H "Authorization: <アクセストークン>"
レスポンス
Hello World!!
無事にAPI Gatewayの後続のLambdaをInvokeして正常なレスポンスを受け取ることができました。